From 662829c8dcf52d2d14971b59078a0972738e5643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 15 May 2026 16:17:07 +0200 Subject: [PATCH 1/6] Add release preparation scripts Add dependency-free Node scripts for release metadata validation and release PR preparation. --- package.json | 4 +- scripts/release-metadata.mjs | 198 ++++++++++++++++++++++++++++++ scripts/release-metadata.test.mjs | 119 ++++++++++++++++++ scripts/release-prepare.mjs | 174 ++++++++++++++++++++++++++ scripts/release-validate.mjs | 90 ++++++++++++++ 5 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 scripts/release-metadata.mjs create mode 100644 scripts/release-metadata.test.mjs create mode 100755 scripts/release-prepare.mjs create mode 100755 scripts/release-validate.mjs diff --git a/package.json b/package.json index b346b012..a33af571 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,9 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs", + "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs scripts/release-metadata.test.mjs", + "release:prepare": "node scripts/release-prepare.mjs", + "release:validate": "node scripts/release-validate.mjs", "validate": "npm run check && npm run test && npm run build" }, "dependencies": { diff --git a/scripts/release-metadata.mjs b/scripts/release-metadata.mjs new file mode 100644 index 00000000..76dff855 --- /dev/null +++ b/scripts/release-metadata.mjs @@ -0,0 +1,198 @@ +import { readFileSync, writeFileSync } from 'node:fs'; + +export const productionCrateManifests = [ + 'crates/forkpress-cli/Cargo.toml', + 'crates/forkpress-core/Cargo.toml', + 'crates/forkpress-runtime/Cargo.toml', + 'crates/forkpress-storage/Cargo.toml', + 'crates/forkpress-server/Cargo.toml', + 'crates/forkpress-git/Cargo.toml', +]; + +export const productionPackageNames = productionCrateManifests + .map((path) => path.match(/crates\/([^/]+)\/Cargo\.toml$/)?.[1]) + .filter(Boolean) + .sort(); + +export const installerManifest = 'installer/windows/ForkPress.iss'; +export const cargoLock = 'Cargo.lock'; + +const cargoVersionLine = /^version = "([^"]+)"$/m; +const installerVersionLine = /^#define AppVersion "([^"]+)"$/m; +const semverPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$/; + +export class ReleaseMetadataError extends Error { + constructor(message) { + super(message); + this.name = 'ReleaseMetadataError'; + } +} + +export function assertReleaseVersion(version) { + if (!version || version.startsWith('v')) { + throw new ReleaseMetadataError('Release version must be passed without a leading v.'); + } + if (!semverPattern.test(version)) { + throw new ReleaseMetadataError( + `Release version must be a semantic version such as 0.1.13 or 0.2.0: ${version}`, + ); + } + return version; +} + +export function tagForVersion(version) { + return `v${assertReleaseVersion(version)}`; +} + +export function releaseBranchForVersion(version) { + return `release/${tagForVersion(version)}`; +} + +export function readCargoTomlVersion(contents, path = 'Cargo.toml') { + const match = contents.match(cargoVersionLine); + if (!match) { + throw new ReleaseMetadataError(`Could not find package version in ${path}.`); + } + return match[1]; +} + +export function setCargoTomlVersion(contents, version, path = 'Cargo.toml') { + assertReleaseVersion(version); + if (!cargoVersionLine.test(contents)) { + throw new ReleaseMetadataError(`Could not find package version in ${path}.`); + } + return contents.replace(cargoVersionLine, `version = "${version}"`); +} + +export function readInstallerVersion(contents, path = installerManifest) { + const match = contents.match(installerVersionLine); + if (!match) { + throw new ReleaseMetadataError(`Could not find AppVersion in ${path}.`); + } + return match[1]; +} + +export function setInstallerVersion(contents, version, path = installerManifest) { + assertReleaseVersion(version); + if (!installerVersionLine.test(contents)) { + throw new ReleaseMetadataError(`Could not find AppVersion in ${path}.`); + } + return contents.replace(installerVersionLine, `#define AppVersion "${version}"`); +} + +export function readCargoLockPackageVersions(contents, packageNames) { + const requested = new Set(packageNames); + const versions = new Map(); + const blocks = contents.split(/\n(?=\[\[package\]\]\n)/); + for (const block of blocks) { + const name = block.match(/^name = "([^"]+)"$/m)?.[1]; + if (!name || !requested.has(name)) { + continue; + } + const version = block.match(/^version = "([^"]+)"$/m)?.[1]; + if (!version) { + throw new ReleaseMetadataError(`Could not find Cargo.lock version for ${name}.`); + } + versions.set(name, version); + } + return versions; +} + +export function updateReleaseMetadata(repoRoot, version) { + assertReleaseVersion(version); + const changed = []; + + for (const path of productionCrateManifests) { + const fullPath = `${repoRoot}/${path}`; + const before = readFileSync(fullPath, 'utf8'); + const after = setCargoTomlVersion(before, version, path); + if (after !== before) { + writeFileSync(fullPath, after); + changed.push(path); + } + } + + const installerPath = `${repoRoot}/${installerManifest}`; + const installerBefore = readFileSync(installerPath, 'utf8'); + const installerAfter = setInstallerVersion(installerBefore, version, installerManifest); + if (installerAfter !== installerBefore) { + writeFileSync(installerPath, installerAfter); + changed.push(installerManifest); + } + + return changed; +} + +export function validateReleaseMetadata(repoRoot, options = {}) { + const expectedVersion = options.version ? assertReleaseVersion(options.version) : null; + const crateVersions = new Map(); + + for (const path of productionCrateManifests) { + const version = readCargoTomlVersion(readFileSync(`${repoRoot}/${path}`, 'utf8'), path); + crateVersions.set(path, version); + } + + const versions = new Set(crateVersions.values()); + if (versions.size !== 1) { + const rendered = [...crateVersions.entries()] + .map(([path, version]) => `${path}: ${version}`) + .join('\n'); + throw new ReleaseMetadataError(`ForkPress crate versions disagree:\n${rendered}`); + } + + const version = [...versions][0]; + assertReleaseVersion(version); + if (expectedVersion && version !== expectedVersion) { + throw new ReleaseMetadataError( + `ForkPress crate version ${version} does not match requested release ${expectedVersion}.`, + ); + } + + const installerVersion = readInstallerVersion( + readFileSync(`${repoRoot}/${installerManifest}`, 'utf8'), + installerManifest, + ); + if (installerVersion !== version) { + throw new ReleaseMetadataError( + `${installerManifest} AppVersion ${installerVersion} does not match crate version ${version}.`, + ); + } + + const lockVersions = readCargoLockPackageVersions( + readFileSync(`${repoRoot}/${cargoLock}`, 'utf8'), + productionPackageNames, + ); + for (const packageName of productionPackageNames) { + const lockVersion = lockVersions.get(packageName); + if (!lockVersion) { + throw new ReleaseMetadataError(`Cargo.lock is missing ${packageName}.`); + } + if (lockVersion !== version) { + throw new ReleaseMetadataError( + `Cargo.lock has ${packageName} ${lockVersion}, expected ${version}.`, + ); + } + } + + const tag = tagForVersion(version); + if (options.releaseBranch) { + const expectedBranch = releaseBranchForVersion(version); + if (options.releaseBranch !== expectedBranch) { + throw new ReleaseMetadataError( + `Release branch ${options.releaseBranch} does not match metadata version ${version}; expected ${expectedBranch}.`, + ); + } + } + + return { + version, + tag, + branch: releaseBranchForVersion(version), + files: [ + ...productionCrateManifests, + installerManifest, + cargoLock, + ], + }; +} diff --git a/scripts/release-metadata.test.mjs b/scripts/release-metadata.test.mjs new file mode 100644 index 00000000..e8cdb55b --- /dev/null +++ b/scripts/release-metadata.test.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + ReleaseMetadataError, + assertReleaseVersion, + cargoLock, + installerManifest, + productionCrateManifests, + productionPackageNames, + readCargoLockPackageVersions, + releaseBranchForVersion, + setCargoTomlVersion, + setInstallerVersion, + tagForVersion, + validateReleaseMetadata, +} from './release-metadata.mjs'; + +test('validates release versions and derived names', () => { + assert.equal(assertReleaseVersion('0.1.13'), '0.1.13'); + assert.equal(tagForVersion('0.2.0'), 'v0.2.0'); + assert.equal(releaseBranchForVersion('1.0.0'), 'release/v1.0.0'); + assert.throws(() => assertReleaseVersion('v0.1.13'), ReleaseMetadataError); + assert.throws(() => assertReleaseVersion('1.2'), ReleaseMetadataError); +}); + +test('updates anchored release metadata lines', () => { + assert.equal( + setCargoTomlVersion('[package]\nname = "forkpress-cli"\nversion = "0.1.12"\n', '0.1.13'), + '[package]\nname = "forkpress-cli"\nversion = "0.1.13"\n', + ); + assert.equal( + setInstallerVersion('#define AppName "ForkPress"\n#define AppVersion "0.1.12"\n', '0.1.13'), + '#define AppName "ForkPress"\n#define AppVersion "0.1.13"\n', + ); +}); + +test('reads selected package versions from Cargo.lock', () => { + const versions = readCargoLockPackageVersions( + `[[package]] +name = "forkpress-cli" +version = "0.1.13" + +[[package]] +name = "other" +version = "1.0.0" +`, + ['forkpress-cli'], + ); + + assert.deepEqual([...versions.entries()], [['forkpress-cli', '0.1.13']]); +}); + +test('validates consistent release metadata in a project fixture', (t) => { + const project = createProject(t, '0.1.13'); + + assert.deepEqual(validateReleaseMetadata(project, { version: '0.1.13' }), { + version: '0.1.13', + tag: 'v0.1.13', + branch: 'release/v0.1.13', + files: [...productionCrateManifests, installerManifest, cargoLock], + }); +}); + +test('rejects mismatched release branch metadata', (t) => { + const project = createProject(t, '0.1.13'); + + assert.throws( + () => validateReleaseMetadata(project, { releaseBranch: 'release/v0.2.0' }), + /does not match metadata version/, + ); +}); + +function createProject(t, version) { + const projectRoot = mkdtempSync(path.join(tmpdir(), 'forkpress-release-')); + t.after(() => rmSync(projectRoot, { recursive: true, force: true })); + + for (const manifest of productionCrateManifests) { + writeFixture( + projectRoot, + manifest, + `[package] +name = "${manifest.match(/crates\/([^/]+)\//)[1]}" +version = "${version}" +edition = "2024" +`, + ); + } + writeFixture( + projectRoot, + installerManifest, + `#define AppName "ForkPress" +#define AppVersion "${version}" +`, + ); + writeFixture( + projectRoot, + cargoLock, + productionPackageNames + .map( + (packageName) => `[[package]] +name = "${packageName}" +version = "${version}" +`, + ) + .join('\n'), + ); + + return projectRoot; +} + +function writeFixture(projectRoot, relativePath, contents) { + const fullPath = path.join(projectRoot, relativePath); + mkdirSync(path.dirname(fullPath), { recursive: true }); + writeFileSync(fullPath, contents); +} diff --git a/scripts/release-prepare.mjs b/scripts/release-prepare.mjs new file mode 100755 index 00000000..d9067119 --- /dev/null +++ b/scripts/release-prepare.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { + ReleaseMetadataError, + assertReleaseVersion, + cargoLock, + installerManifest, + productionCrateManifests, + releaseBranchForVersion, + tagForVersion, + updateReleaseMetadata, + validateReleaseMetadata, +} from './release-metadata.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..'); + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} + +export function main(argv) { + try { + const version = parseArgs(argv); + const tag = tagForVersion(version); + const branch = releaseBranchForVersion(version); + + requireCleanWorktree(); + requireCommand('gh'); + fetchReleaseBase(); + requireRefMissing(branch, tag); + + run('git', ['switch', '--create', branch, 'origin/trunk']); + const changed = updateReleaseMetadata(repoRoot, version); + run('cargo', ['update', '--workspace']); + validateReleaseMetadata(repoRoot, { version, releaseBranch: branch }); + requireExpectedChanges(changed); + + run('git', ['add', ...productionCrateManifests, installerManifest, cargoLock]); + run('git', [ + 'commit', + '-m', + `Prepare release ${tag}`, + '-m', + `Update ForkPress release metadata for ${tag}.`, + ]); + run('git', ['push', '--set-upstream', 'origin', branch]); + run('gh', [ + 'pr', + 'create', + '--base', + 'trunk', + '--head', + branch, + '--title', + `Prepare release ${tag}`, + '--body', + `Updates ForkPress release metadata for ${tag}.`, + ]); + console.log(`Opened release PR for ${tag}.`); + } catch (error) { + if (error instanceof ReleaseMetadataError) { + console.error(error.message); + process.exit(1); + } + throw error; + } +} + +function parseArgs(argv) { + if (argv.length !== 1 || argv[0] === '--help') { + printUsage(); + process.exit(argv[0] === '--help' ? 0 : 1); + } + return assertReleaseVersion(argv[0]); +} + +function printUsage() { + console.log(`Usage: node scripts/release-prepare.mjs + +Example: + node scripts/release-prepare.mjs 0.1.13 +`); +} + +function requireCleanWorktree() { + const status = runOutput('git', ['status', '--porcelain']); + if (status.trim() !== '') { + throw new ReleaseMetadataError('Release prepare requires a clean worktree.'); + } +} + +function requireCommand(command) { + try { + run(command, ['--version'], { stdio: 'ignore' }); + } catch { + throw new ReleaseMetadataError(`Required command is not available: ${command}`); + } +} + +function fetchReleaseBase() { + run('git', ['fetch', '--prune', 'origin', 'trunk:refs/remotes/origin/trunk']); + run('git', ['fetch', '--tags', 'origin']); +} + +function requireRefMissing(branch, tag) { + if (gitRefExists(`refs/heads/${branch}`)) { + throw new ReleaseMetadataError(`Local branch already exists: ${branch}`); + } + if (remoteRefExists(['--heads', 'origin', branch])) { + throw new ReleaseMetadataError(`Remote branch already exists: ${branch}`); + } + if (gitRefExists(`refs/tags/${tag}`)) { + throw new ReleaseMetadataError(`Local tag already exists: ${tag}`); + } + if (remoteRefExists(['--tags', 'origin', tag])) { + throw new ReleaseMetadataError(`Remote tag already exists: ${tag}`); + } +} + +function gitRefExists(ref) { + try { + run('git', ['show-ref', '--verify', '--quiet', ref], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function remoteRefExists(args) { + try { + run('git', ['ls-remote', '--exit-code', ...args], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +function requireExpectedChanges(initialChangedFiles) { + const status = runOutput('git', ['status', '--porcelain']).trim(); + if (status === '') { + throw new ReleaseMetadataError('Release metadata is already at the requested version.'); + } + + const changedFiles = new Set( + status + .split('\n') + .map((line) => line.slice(3)) + .filter(Boolean), + ); + for (const file of [...initialChangedFiles, cargoLock]) { + if (!changedFiles.has(file)) { + throw new ReleaseMetadataError(`Expected release file was not changed: ${file}`); + } + } +} + +function run(command, args, options = {}) { + return execFileSync(command, args, { + cwd: repoRoot, + stdio: 'inherit', + ...options, + }); +} + +function runOutput(command, args) { + return execFileSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + }); +} diff --git a/scripts/release-validate.mjs b/scripts/release-validate.mjs new file mode 100755 index 00000000..587360d9 --- /dev/null +++ b/scripts/release-validate.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import { appendFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { ReleaseMetadataError, validateReleaseMetadata } from './release-metadata.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, '..'); + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} + +export function main(argv) { + try { + const options = parseArgs(argv); + const metadata = validateReleaseMetadata(repoRoot, { + version: options.version, + releaseBranch: options.releaseBranch, + }); + if (options.githubOutput) { + appendFileSync(process.env.GITHUB_OUTPUT, `version=${metadata.version}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `tag=${metadata.tag}\n`); + appendFileSync(process.env.GITHUB_OUTPUT, `branch=${metadata.branch}\n`); + } + if (options.printVersion) { + console.log(metadata.version); + return; + } + console.log(`Release metadata is valid for ${metadata.tag}.`); + } catch (error) { + if (error instanceof ReleaseMetadataError) { + console.error(error.message); + process.exit(1); + } + throw error; + } +} + +function parseArgs(argv) { + const options = { + version: null, + releaseBranch: null, + githubOutput: false, + printVersion: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--release-branch') { + options.releaseBranch = readValue(argv, index, arg); + index += 1; + } else if (arg === '--github-output') { + if (!process.env.GITHUB_OUTPUT) { + throw new ReleaseMetadataError('--github-output requires GITHUB_OUTPUT.'); + } + options.githubOutput = true; + } else if (arg === '--print-version') { + options.printVersion = true; + } else if (arg === '--help') { + printUsage(); + process.exit(0); + } else if (arg.startsWith('-')) { + throw new ReleaseMetadataError(`Unknown option: ${arg}`); + } else if (!options.version) { + options.version = arg; + } else { + throw new ReleaseMetadataError(`Unexpected argument: ${arg}`); + } + } + return options; +} + +function readValue(argv, index, option) { + const value = argv[index + 1]; + if (!value || value.startsWith('-')) { + throw new ReleaseMetadataError(`${option} requires a value.`); + } + return value; +} + +function printUsage() { + console.log(`Usage: node scripts/release-validate.mjs [version] [options] + +Options: + --release-branch Verify branch name matches release/v. + --github-output Write version, tag, and branch to GITHUB_OUTPUT. + --print-version Print the validated version only. +`); +} From 738a83d132288061e770857d929bcb07f7325eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 15 May 2026 16:17:10 +0200 Subject: [PATCH 2/6] Wire release prepare and publish workflows Add a manual release preparation workflow and make release publishing run from merged release PRs instead of tag pushes. --- .github/workflows/release-prepare.yml | 48 ++++++ .../{release.yml => release-publish.yml} | 154 ++++++++++++++++-- 2 files changed, 191 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release-prepare.yml rename .github/workflows/{release.yml => release-publish.yml} (60%) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 00000000..d41f028c --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,48 @@ +name: release:prepare + +on: + workflow_dispatch: + inputs: + version: + description: "Release version, for example 0.1.13" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + runs-on: ubuntu-latest + env: + ASTRO_TELEMETRY_DISABLED: "1" + GH_TOKEN: ${{ secrets.RELEASE_PREPARE_TOKEN != '' && secrets.RELEASE_PREPARE_TOKEN || github.token }} + RELEASE_PREPARE_TOKEN_PRESENT: ${{ secrets.RELEASE_PREPARE_TOKEN != '' }} + steps: + - uses: actions/checkout@v4 + with: + ref: trunk + token: ${{ secrets.RELEASE_PREPARE_TOKEN != '' && secrets.RELEASE_PREPARE_TOKEN || github.token }} + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Configure Git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Warn when using GITHUB_TOKEN + if: env.RELEASE_PREPARE_TOKEN_PRESENT != 'true' + run: | + echo "::warning::RELEASE_PREPARE_TOKEN is not configured. The PR can be created with GITHUB_TOKEN, but PR-triggered checks may not run until manually retriggered." + + - name: Prepare release PR + run: npm run release:prepare -- "${{ inputs.version }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release-publish.yml similarity index 60% rename from .github/workflows/release.yml rename to .github/workflows/release-publish.yml index e01cce05..ef0a1411 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release-publish.yml @@ -1,15 +1,15 @@ -name: release +name: release:publish on: - push: - tags: ["v*"] - workflow_dispatch: pull_request: + types: [opened, synchronize, reopened, closed] + branches: ["trunk"] paths: - "crates/**" - "runtime/cow/**" - "runtime/wp.zip" - "scripts/build-dist.sh" + - "scripts/release-*.mjs" - "scripts/windows/**" - "scripts/cow/**" - "scripts/git/**" @@ -20,11 +20,80 @@ on: - "vendor/**" - "Cargo.toml" - "Cargo.lock" - - ".github/workflows/release.yml" + - "package.json" + - ".github/workflows/release-prepare.yml" + - ".github/workflows/release-publish.yml" + +permissions: + contents: read jobs: + preflight: + name: preflight + if: >- + startsWith(github.event.pull_request.head.ref, 'release/v') && + github.event.pull_request.head.repo.full_name == github.repository && + ( + github.event.action != 'closed' || + github.event.pull_request.merged == true + ) + runs-on: ubuntu-latest + outputs: + version: ${{ steps.metadata.outputs.version }} + tag: ${{ steps.metadata.outputs.tag }} + branch: ${{ steps.metadata.outputs.branch }} + checkout_ref: ${{ steps.ref.outputs.checkout_ref }} + should_publish: ${{ steps.mode.outputs.should_publish }} + steps: + - name: Select checkout ref + id: ref + run: | + if [ "${{ github.event.pull_request.merged }}" = "true" ]; then + echo "checkout_ref=${{ github.event.pull_request.merge_commit_sha }}" >> "$GITHUB_OUTPUT" + else + echo "checkout_ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" + fi + + - uses: actions/checkout@v4 + with: + ref: ${{ steps.ref.outputs.checkout_ref }} + fetch-depth: 0 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Validate release metadata + id: metadata + run: node scripts/release-validate.mjs --release-branch "${{ github.event.pull_request.head.ref }}" --github-output + + - name: Validate tag state + run: | + git fetch --force --tags origin + tag="${{ steps.metadata.outputs.tag }}" + sha="$(git rev-parse HEAD)" + existing="$(git rev-parse -q --verify "refs/tags/${tag}^{}" 2>/dev/null || true)" + if [ -n "$existing" ] && [ "$existing" != "$sha" ]; then + echo "Tag $tag already points at $existing, expected $sha." >&2 + exit 1 + fi + + - name: Select publish mode + id: mode + run: | + should_publish=false + if [ "${{ github.event.action }}" = "closed" ]; then + git fetch origin trunk:refs/remotes/origin/trunk + sha="$(git rev-parse HEAD)" + git merge-base --is-ancestor "$sha" origin/trunk + should_publish=true + fi + echo "should_publish=$should_publish" >> "$GITHUB_OUTPUT" + build: name: build ${{ matrix.target }} + needs: preflight + if: needs.preflight.result == 'success' strategy: fail-fast: false matrix: @@ -50,6 +119,8 @@ jobs: WINDOWS_CODESIGN_PASSWORD: ${{ secrets.WINDOWS_CODESIGN_PASSWORD }} steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.checkout_ref }} - name: Release preflight checks if: matrix.os != 'windows' @@ -113,11 +184,11 @@ jobs: - name: Build forkpress run: cargo build --release --target ${{ matrix.target }} -p forkpress-cli --bin forkpress - - name: Require signing for tagged Windows releases - if: matrix.os == 'windows' && startsWith(github.ref, 'refs/tags/v') && env.WINDOWS_CODESIGN_CERT_BASE64 == '' + - name: Require signing for published Windows releases + if: matrix.os == 'windows' && needs.preflight.outputs.should_publish == 'true' && env.WINDOWS_CODESIGN_CERT_BASE64 == '' shell: pwsh run: | - throw 'Tagged Windows releases require WINDOWS_CODESIGN_CERT_BASE64 and WINDOWS_CODESIGN_PASSWORD secrets.' + throw 'Published Windows releases require WINDOWS_CODESIGN_CERT_BASE64 and WINDOWS_CODESIGN_PASSWORD secrets.' - name: Sign forkpress.exe (windows) if: matrix.os == 'windows' && env.WINDOWS_CODESIGN_CERT_BASE64 != '' @@ -130,6 +201,23 @@ jobs: cd target/${{ matrix.target }}/release tar -czf ${{ github.workspace }}/forkpress-${{ matrix.target }}.tar.gz forkpress + - name: Smoke packaged artifact + if: matrix.os != 'windows' + run: | + archive="${{ github.workspace }}/forkpress-${{ matrix.target }}.tar.gz" + test -f "$archive" + extract="$(mktemp -d)" + tar -xzf "$archive" -C "$extract" + test -x "$extract/forkpress" + "$extract/forkpress" --version + site="$extract/site" + mkdir -p "$site" + ( + cd "$site" + "$extract/forkpress" init --work-dir "$site/.forkpress" --site-title "CI Smoke Site" + ) + test -f "$site/.forkpress/site.toml" + - name: Package (windows) if: matrix.os == 'windows' shell: pwsh @@ -143,7 +231,7 @@ jobs: $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" & $iscc installer/windows/ForkPress.iss ` /DSourceDir="$stage" ` - /DAppVersion="${{ github.ref_name }}" ` + /DAppVersion="${{ needs.preflight.outputs.version }}" ` /O"${{ github.workspace }}" - name: Sign installer (windows) @@ -222,20 +310,64 @@ jobs: release: name: publish release - needs: build + needs: + - preflight + - build runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') + if: needs.preflight.outputs.should_publish == 'true' permissions: contents: write steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.preflight.outputs.checkout_ref }} + fetch-depth: 0 + - uses: actions/download-artifact@v4 with: pattern: forkpress-* merge-multiple: true + + - name: Verify release artifacts + run: | + expected=( + forkpress-aarch64-apple-darwin.tar.gz + forkpress-x86_64-apple-darwin.tar.gz + forkpress-aarch64-unknown-linux-musl.tar.gz + forkpress-x86_64-unknown-linux-musl.tar.gz + forkpress-x86_64-pc-windows-msvc.zip + ForkPressSetup.exe + ) + for file in "${expected[@]}"; do + test -f "$file" + done + sha256sum "${expected[@]}" > SHA256SUMS + + - name: Create release tag + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch --force --tags origin + tag="${{ needs.preflight.outputs.tag }}" + sha="$(git rev-parse HEAD)" + existing="$(git rev-parse -q --verify "refs/tags/${tag}^{}" 2>/dev/null || true)" + if [ -z "$existing" ]; then + git tag -a "$tag" "$sha" -m "Release $tag" + git push origin "$tag" + elif [ "$existing" = "$sha" ]; then + echo "Tag $tag already points at $sha; continuing." + else + echo "Tag $tag already points at $existing, expected $sha." >&2 + exit 1 + fi + - uses: softprops/action-gh-release@v2 with: + tag_name: ${{ needs.preflight.outputs.tag }} + target_commitish: ${{ needs.preflight.outputs.checkout_ref }} files: | forkpress-*.tar.gz forkpress-*.zip ForkPressSetup.exe + SHA256SUMS generate_release_notes: true From 2117435277fb5138322c4e2dfd9f944125c6e4c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 15 May 2026 16:41:21 +0200 Subject: [PATCH 3/6] Update Homebrew tap during releases Generate a ForkPress formula from release checksums and push it to Automattic/homebrew-tap after publishing release assets. --- .github/workflows/release-publish.yml | 32 ++++++ package.json | 2 +- scripts/release-homebrew-formula.mjs | 120 ++++++++++++++++++++++ scripts/release-homebrew-formula.test.mjs | 41 ++++++++ 4 files changed, 194 insertions(+), 1 deletion(-) create mode 100755 scripts/release-homebrew-formula.mjs create mode 100644 scripts/release-homebrew-formula.test.mjs diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index ef0a1411..038f8aa2 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -317,12 +317,21 @@ jobs: if: needs.preflight.outputs.should_publish == 'true' permissions: contents: write + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} steps: - uses: actions/checkout@v4 with: ref: ${{ needs.preflight.outputs.checkout_ref }} fetch-depth: 0 + - name: Require Homebrew tap token + run: | + if [ -z "$HOMEBREW_TAP_TOKEN" ]; then + echo "HOMEBREW_TAP_TOKEN is required to update Automattic/homebrew-tap." >&2 + exit 1 + fi + - uses: actions/download-artifact@v4 with: pattern: forkpress-* @@ -371,3 +380,26 @@ jobs: ForkPressSetup.exe SHA256SUMS generate_release_notes: true + + - uses: actions/checkout@v4 + with: + repository: Automattic/homebrew-tap + ref: master + path: homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Update Homebrew tap + run: | + node scripts/release-homebrew-formula.mjs "${{ needs.preflight.outputs.version }}" SHA256SUMS > homebrew-tap/forkpress.rb + ruby -c homebrew-tap/forkpress.rb + git -C homebrew-tap config user.name "github-actions[bot]" + git -C homebrew-tap config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git -C homebrew-tap diff --quiet -- forkpress.rb; then + echo "Automattic/homebrew-tap already has forkpress.rb for ${{ needs.preflight.outputs.tag }}." + exit 0 + fi + git -C homebrew-tap add forkpress.rb + git -C homebrew-tap commit \ + -m "Update ForkPress to ${{ needs.preflight.outputs.tag }}" \ + -m "Release: https://github.com/Automattic/forkpress/releases/tag/${{ needs.preflight.outputs.tag }}" + git -C homebrew-tap push origin HEAD:master diff --git a/package.json b/package.json index a33af571..4dccadeb 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs scripts/release-metadata.test.mjs", + "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs scripts/release-metadata.test.mjs scripts/release-homebrew-formula.test.mjs", "release:prepare": "node scripts/release-prepare.mjs", "release:validate": "node scripts/release-validate.mjs", "validate": "npm run check && npm run test && npm run build" diff --git a/scripts/release-homebrew-formula.mjs b/scripts/release-homebrew-formula.mjs new file mode 100755 index 00000000..5f5ba9ab --- /dev/null +++ b/scripts/release-homebrew-formula.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; + +import { ReleaseMetadataError, assertReleaseVersion, tagForVersion } from './release-metadata.mjs'; + +export const homebrewAssets = [ + { + condition: ['on_macos', 'on_arm'], + name: 'forkpress-aarch64-apple-darwin.tar.gz', + }, + { + condition: ['on_macos', 'on_intel'], + name: 'forkpress-x86_64-apple-darwin.tar.gz', + }, + { + condition: ['on_linux', 'on_arm'], + name: 'forkpress-aarch64-unknown-linux-musl.tar.gz', + }, + { + condition: ['on_linux', 'on_intel'], + name: 'forkpress-x86_64-unknown-linux-musl.tar.gz', + }, +]; + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} + +export function main(argv) { + try { + if (argv.length !== 2 || argv[0] === '--help') { + printUsage(); + process.exit(argv[0] === '--help' ? 0 : 1); + } + const [version, sumsPath] = argv; + const sums = parseSha256Sums(readFileSync(sumsPath, 'utf8')); + process.stdout.write(generateHomebrewFormula(version, sums)); + } catch (error) { + if (error instanceof ReleaseMetadataError) { + console.error(error.message); + process.exit(1); + } + throw error; + } +} + +export function parseSha256Sums(contents) { + const sums = new Map(); + for (const line of contents.split(/\r?\n/)) { + if (line.trim() === '') { + continue; + } + const match = line.match(/^([0-9a-fA-F]{64})\s+\*?(.+)$/); + if (!match) { + throw new ReleaseMetadataError(`Invalid SHA256SUMS line: ${line}`); + } + sums.set(match[2], match[1].toLowerCase()); + } + return sums; +} + +export function generateHomebrewFormula(version, sums) { + assertReleaseVersion(version); + const tag = tagForVersion(version); + const assetStanzas = groupedFormulaAssetStanzas(tag, sums); + + return `class Forkpress < Formula + desc "Single-binary WordPress branching environment" + homepage "https://github.com/Automattic/forkpress" + version "${version}" + license "GPL-2.0-only" + +${assetStanzas.join('\n\n')} + + def install + bin.install "forkpress" + end + + test do + assert_match version.to_s, shell_output("#{bin}/forkpress --version") + end +end +`; +} + +function groupedFormulaAssetStanzas(tag, sums) { + const byPlatform = new Map(); + for (const asset of homebrewAssets) { + const [platform, arch] = asset.condition; + const sha256 = sums.get(asset.name); + if (!sha256) { + throw new ReleaseMetadataError(`SHA256SUMS is missing ${asset.name}.`); + } + if (!byPlatform.has(platform)) { + byPlatform.set(platform, []); + } + byPlatform.get(platform).push(formulaAssetArchStanza(tag, arch, asset.name, sha256)); + } + return [...byPlatform.entries()].map( + ([platform, stanzas]) => ` ${platform} do +${stanzas.join('\n\n')} + end`, + ); +} + +function formulaAssetArchStanza(tag, arch, name, sha256) { + return ` ${arch} do + url "https://github.com/Automattic/forkpress/releases/download/${tag}/${name}" + sha256 "${sha256}" + end`; +} + +function printUsage() { + console.log(`Usage: node scripts/release-homebrew-formula.mjs + +Example: + node scripts/release-homebrew-formula.mjs 0.1.13 SHA256SUMS > forkpress.rb +`); +} diff --git a/scripts/release-homebrew-formula.test.mjs b/scripts/release-homebrew-formula.test.mjs new file mode 100644 index 00000000..a11bb289 --- /dev/null +++ b/scripts/release-homebrew-formula.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + generateHomebrewFormula, + homebrewAssets, + parseSha256Sums, +} from './release-homebrew-formula.mjs'; + +test('parses SHA256SUMS entries used by Homebrew', () => { + const sums = parseSha256Sums(` +${'a'.repeat(64)} forkpress-aarch64-apple-darwin.tar.gz +${'b'.repeat(64)} *forkpress-x86_64-apple-darwin.tar.gz +`); + + assert.equal(sums.get('forkpress-aarch64-apple-darwin.tar.gz'), 'a'.repeat(64)); + assert.equal(sums.get('forkpress-x86_64-apple-darwin.tar.gz'), 'b'.repeat(64)); +}); + +test('generates a ForkPress formula with platform-specific assets', () => { + const sums = new Map(homebrewAssets.map((asset, index) => [asset.name, `${index}`.repeat(64)])); + const formula = generateHomebrewFormula('0.1.13', sums); + + assert.match(formula, /class Forkpress < Formula/); + assert.match(formula, /version "0\.1\.13"/); + assert.match(formula, /license "GPL-2\.0-only"/); + for (const asset of homebrewAssets) { + assert.match( + formula, + new RegExp(`https://github.com/Automattic/forkpress/releases/download/v0\\.1\\.13/${asset.name}`), + ); + } + assert.match(formula, /assert_match version\.to_s, shell_output/); +}); + +test('rejects formula generation when a required checksum is missing', () => { + assert.throws( + () => generateHomebrewFormula('0.1.13', new Map()), + /SHA256SUMS is missing forkpress-aarch64-apple-darwin\.tar\.gz/, + ); +}); From 2713251b0ddb23d038df924b9ce330af93aa9920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 15 May 2026 16:46:41 +0200 Subject: [PATCH 4/6] Add curl installer Add a checksum-verifying macOS/Linux install script for GitHub release tarballs and document the curl-based install path. --- README.md | 14 +++++ package.json | 2 +- scripts/install.sh | 132 +++++++++++++++++++++++++++++++++++++++ scripts/install.test.mjs | 52 +++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100755 scripts/install.sh create mode 100644 scripts/install.test.mjs diff --git a/README.md b/README.md index 9719d855..46e0ca49 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,20 @@ Download the release for your platform from ### macOS and Linux +Install the latest release into `$HOME/.local/bin`: + +```bash +curl -fsSL https://raw.githubusercontent.com/Automattic/forkpress/trunk/scripts/install.sh | sh +``` + +Install a specific release: + +```bash +curl -fsSL https://raw.githubusercontent.com/Automattic/forkpress/trunk/scripts/install.sh | FORKPRESS_VERSION=0.1.13 sh +``` + +Or download an archive manually: + ```bash curl -L -o forkpress.tar.gz \ "https://github.com/Automattic/forkpress/releases/download//forkpress-.tar.gz" diff --git a/package.json b/package.json index 4dccadeb..bd1aaa2b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs scripts/release-metadata.test.mjs scripts/release-homebrew-formula.test.mjs", + "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs scripts/release-metadata.test.mjs scripts/release-homebrew-formula.test.mjs scripts/install.test.mjs", "release:prepare": "node scripts/release-prepare.mjs", "release:validate": "node scripts/release-validate.mjs", "validate": "npm run check && npm run test && npm run build" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 00000000..10e3de46 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,132 @@ +#!/bin/sh +set -eu + +repo="${FORKPRESS_REPO:-Automattic/forkpress}" +version="${FORKPRESS_VERSION:-latest}" +install_dir="${FORKPRESS_INSTALL_DIR:-$HOME/.local/bin}" +target="${FORKPRESS_TARGET:-}" + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "forkpress install: missing required command: $1" >&2 + exit 1 + fi +} + +detect_target() { + if [ -n "$target" ]; then + printf '%s\n' "$target" + return + fi + + os="$(uname -s)" + arch="$(uname -m)" + case "$os-$arch" in + Darwin-arm64) printf '%s\n' "aarch64-apple-darwin" ;; + Darwin-x86_64) printf '%s\n' "x86_64-apple-darwin" ;; + Linux-aarch64|Linux-arm64) printf '%s\n' "aarch64-unknown-linux-musl" ;; + Linux-x86_64|Linux-amd64) printf '%s\n' "x86_64-unknown-linux-musl" ;; + *) + echo "forkpress install: unsupported platform: $os $arch" >&2 + exit 1 + ;; + esac +} + +release_base_url() { + if [ -n "${FORKPRESS_RELEASE_BASE_URL:-}" ]; then + printf '%s\n' "${FORKPRESS_RELEASE_BASE_URL%/}" + return + fi + + case "$version" in + latest|"") printf 'https://github.com/%s/releases/latest/download\n' "$repo" ;; + v*) printf 'https://github.com/%s/releases/download/%s\n' "$repo" "$version" ;; + *) printf 'https://github.com/%s/releases/download/v%s\n' "$repo" "$version" ;; + esac +} + +download() { + url="$1" + out="$2" + curl -fsSL "$url" -o "$out" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + else + shasum -a 256 "$1" | awk '{ print $1 }' + fi +} + +expected_sha256() { + asset="$1" + sums="$2" + awk -v asset="$asset" ' + ($2 == asset || $2 == "*" asset) { print tolower($1); found = 1; exit } + END { if (!found) exit 1 } + ' "$sums" +} + +verify_sha256() { + asset="$1" + file="$2" + sums="$3" + expected="$(expected_sha256 "$asset" "$sums")" || { + echo "forkpress install: SHA256SUMS does not include $asset" >&2 + exit 1 + } + actual="$(sha256_file "$file")" + if [ "$actual" != "$expected" ]; then + echo "forkpress install: checksum mismatch for $asset" >&2 + echo "expected: $expected" >&2 + echo "actual: $actual" >&2 + exit 1 + fi +} + +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/forkpress-install.XXXXXX")" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT HUP INT TERM + +need curl +need tar +need awk + +target="$(detect_target)" +asset="forkpress-$target.tar.gz" +base_url="$(release_base_url)" +archive="$tmpdir/$asset" +sums="$tmpdir/SHA256SUMS" +extract="$tmpdir/extract" + +mkdir -p "$extract" "$install_dir" + +echo "forkpress install: downloading $asset" +download "$base_url/$asset" "$archive" +download "$base_url/SHA256SUMS" "$sums" +verify_sha256 "$asset" "$archive" "$sums" + +tar -xzf "$archive" -C "$extract" +if [ ! -f "$extract/forkpress" ]; then + echo "forkpress install: archive did not contain forkpress" >&2 + exit 1 +fi + +cp "$extract/forkpress" "$install_dir/forkpress" +chmod 0755 "$install_dir/forkpress" + +"$install_dir/forkpress" --version + +case ":$PATH:" in + *":$install_dir:"*) ;; + *) + echo "forkpress install: $install_dir is not on PATH" >&2 + echo "Add it to PATH or run $install_dir/forkpress directly." >&2 + ;; +esac + +echo "forkpress install: installed $install_dir/forkpress" diff --git a/scripts/install.test.mjs b/scripts/install.test.mjs new file mode 100644 index 00000000..394c9aa6 --- /dev/null +++ b/scripts/install.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +test('curl installer installs and verifies a release asset', (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'forkpress-install-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const release = path.join(root, 'release'); + const build = path.join(root, 'build'); + const installDir = path.join(root, 'bin'); + mkdirSync(release, { recursive: true }); + mkdirSync(build, { recursive: true }); + writeFileSync(path.join(build, 'forkpress'), '#!/bin/sh\necho "forkpress 0.1.13"\n'); + spawn('chmod', ['0755', path.join(build, 'forkpress')]); + + const asset = 'forkpress-test-target.tar.gz'; + const archive = path.join(release, asset); + spawn('tar', ['-czf', archive, '-C', build, 'forkpress']); + const checksum = createHash('sha256').update(readFileSync(archive)).digest('hex'); + writeFileSync(path.join(release, 'SHA256SUMS'), `${checksum} ${asset}\n`); + + const result = spawn('sh', ['scripts/install.sh'], { + env: { + ...process.env, + FORKPRESS_INSTALL_DIR: installDir, + FORKPRESS_RELEASE_BASE_URL: `file://${release}`, + FORKPRESS_TARGET: 'test-target', + PATH: process.env.PATH, + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /forkpress 0\.1\.13/); + assert.match(spawn(path.join(installDir, 'forkpress'), ['--version']).stdout, /forkpress 0\.1\.13/); +}); + +function spawn(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'), + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed\n${result.stderr}`); + } + return result; +} From 731329137dc3d1d62af06e75b339f39da2aba50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 15 May 2026 16:49:06 +0200 Subject: [PATCH 5/6] Add local curl launcher Add a project-local launcher that downloads, verifies, caches, and execs the matching ForkPress release binary. --- README.md | 15 +++ package.json | 2 +- scripts/forkpress | 156 ++++++++++++++++++++++++++++ scripts/forkpress-launcher.test.mjs | 70 +++++++++++++ 4 files changed, 242 insertions(+), 1 deletion(-) create mode 100755 scripts/forkpress create mode 100644 scripts/forkpress-launcher.test.mjs diff --git a/README.md b/README.md index 46e0ca49..ba68534e 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,21 @@ Install a specific release: curl -fsSL https://raw.githubusercontent.com/Automattic/forkpress/trunk/scripts/install.sh | FORKPRESS_VERSION=0.1.13 sh ``` +To keep ForkPress local to one project, download the launcher instead: + +```bash +curl -fsSL https://raw.githubusercontent.com/Automattic/forkpress/trunk/scripts/forkpress -o forkpress +chmod +x forkpress +./forkpress init +``` + +The launcher caches the real binary in `.forkpress-bin/` next to the launcher. +Pin a project to a specific release with: + +```bash +FORKPRESS_VERSION=0.1.13 ./forkpress serve +``` + Or download an archive manually: ```bash diff --git a/package.json b/package.json index bd1aaa2b..650e9761 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "astro build", "preview": "astro preview", "check": "astro check", - "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs scripts/release-metadata.test.mjs scripts/release-homebrew-formula.test.mjs scripts/install.test.mjs", + "test": "node --test src/remark-rewrite-doc-links.test.mjs src/astro-config.test.mjs scripts/release-metadata.test.mjs scripts/release-homebrew-formula.test.mjs scripts/install.test.mjs scripts/forkpress-launcher.test.mjs", "release:prepare": "node scripts/release-prepare.mjs", "release:validate": "node scripts/release-validate.mjs", "validate": "npm run check && npm run test && npm run build" diff --git a/scripts/forkpress b/scripts/forkpress new file mode 100755 index 00000000..222c859d --- /dev/null +++ b/scripts/forkpress @@ -0,0 +1,156 @@ +#!/bin/sh +set -eu + +repo="${FORKPRESS_REPO:-Automattic/forkpress}" +version="${FORKPRESS_VERSION:-latest}" +target="${FORKPRESS_TARGET:-}" + +need() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "forkpress launcher: missing required command: $1" >&2 + exit 1 + fi +} + +script_dir() { + dirname="$(dirname "$0")" + (cd "$dirname" && pwd -P) +} + +detect_target() { + if [ -n "$target" ]; then + printf '%s\n' "$target" + return + fi + + os="$(uname -s)" + arch="$(uname -m)" + case "$os-$arch" in + Darwin-arm64) printf '%s\n' "aarch64-apple-darwin" ;; + Darwin-x86_64) printf '%s\n' "x86_64-apple-darwin" ;; + Linux-aarch64|Linux-arm64) printf '%s\n' "aarch64-unknown-linux-musl" ;; + Linux-x86_64|Linux-amd64) printf '%s\n' "x86_64-unknown-linux-musl" ;; + *) + echo "forkpress launcher: unsupported platform: $os $arch" >&2 + exit 1 + ;; + esac +} + +version_tag() { + case "$version" in + latest|"") + if [ -n "${FORKPRESS_RELEASE_BASE_URL:-}" ]; then + printf '%s\n' "latest" + return + fi + latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "https://github.com/$repo/releases/latest")" || { + printf '%s\n' "latest" + return + } + case "$latest_url" in + */tag/v*) printf '%s\n' "${latest_url##*/tag/}" ;; + *) printf '%s\n' "latest" ;; + esac + ;; + v*) printf '%s\n' "$version" ;; + *) printf 'v%s\n' "$version" ;; + esac +} + +release_base_url() { + tag="$1" + if [ -n "${FORKPRESS_RELEASE_BASE_URL:-}" ]; then + printf '%s\n' "${FORKPRESS_RELEASE_BASE_URL%/}" + return + fi + + case "$tag" in + latest) printf 'https://github.com/%s/releases/latest/download\n' "$repo" ;; + *) printf 'https://github.com/%s/releases/download/%s\n' "$repo" "$tag" ;; + esac +} + +download() { + url="$1" + out="$2" + curl -fsSL "$url" -o "$out" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + else + shasum -a 256 "$1" | awk '{ print $1 }' + fi +} + +expected_sha256() { + asset="$1" + sums="$2" + awk -v asset="$asset" ' + ($2 == asset || $2 == "*" asset) { print tolower($1); found = 1; exit } + END { if (!found) exit 1 } + ' "$sums" +} + +verify_sha256() { + asset="$1" + file="$2" + sums="$3" + expected="$(expected_sha256 "$asset" "$sums")" || { + echo "forkpress launcher: SHA256SUMS does not include $asset" >&2 + exit 1 + } + actual="$(sha256_file "$file")" + if [ "$actual" != "$expected" ]; then + echo "forkpress launcher: checksum mismatch for $asset" >&2 + echo "expected: $expected" >&2 + echo "actual: $actual" >&2 + exit 1 + fi +} + +need curl +need tar +need awk + +root="$(script_dir)" +target="$(detect_target)" +tag="$(version_tag)" +cache_root="${FORKPRESS_CACHE_DIR:-$root/.forkpress-bin}" +bin_dir="$cache_root/$tag/$target" +bin="$bin_dir/forkpress" + +if [ "${FORKPRESS_REFRESH:-0}" != "1" ] && [ -x "$bin" ]; then + exec "$bin" "$@" +fi + +asset="forkpress-$target.tar.gz" +base_url="$(release_base_url "$tag")" +mkdir -p "$bin_dir" +tmpdir="$(mktemp -d "$cache_root/.download.XXXXXX")" +cleanup() { + rm -rf "$tmpdir" +} +trap cleanup EXIT HUP INT TERM + +archive="$tmpdir/$asset" +sums="$tmpdir/SHA256SUMS" +extract="$tmpdir/extract" +mkdir -p "$extract" + +echo "forkpress launcher: downloading $asset" >&2 +download "$base_url/$asset" "$archive" +download "$base_url/SHA256SUMS" "$sums" +verify_sha256 "$asset" "$archive" "$sums" + +tar -xzf "$archive" -C "$extract" +if [ ! -f "$extract/forkpress" ]; then + echo "forkpress launcher: archive did not contain forkpress" >&2 + exit 1 +fi + +cp "$extract/forkpress" "$bin" +chmod 0755 "$bin" +exec "$bin" "$@" diff --git a/scripts/forkpress-launcher.test.mjs b/scripts/forkpress-launcher.test.mjs new file mode 100644 index 00000000..551f65ec --- /dev/null +++ b/scripts/forkpress-launcher.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +test('local launcher downloads, verifies, caches, and execs forkpress', (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'forkpress-launcher-test-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const project = path.join(root, 'project'); + const release = path.join(root, 'release'); + const build = path.join(root, 'build'); + mkdirSync(project, { recursive: true }); + mkdirSync(release, { recursive: true }); + mkdirSync(build, { recursive: true }); + copyFileSync('scripts/forkpress', path.join(project, 'forkpress')); + spawn('chmod', ['0755', path.join(project, 'forkpress')]); + + writeFileSync(path.join(build, 'forkpress'), '#!/bin/sh\necho "forkpress 0.1.13 $*"\n'); + spawn('chmod', ['0755', path.join(build, 'forkpress')]); + + const asset = 'forkpress-test-target.tar.gz'; + const archive = path.join(release, asset); + spawn('tar', ['-czf', archive, '-C', build, 'forkpress']); + const checksum = createHash('sha256').update(readFileSync(archive)).digest('hex'); + writeFileSync(path.join(release, 'SHA256SUMS'), `${checksum} ${asset}\n`); + + const first = runLauncher(project, release, 'alpha'); + assert.equal(first.status, 0, first.stderr); + assert.match(first.stderr, /downloading forkpress-test-target\.tar\.gz/); + assert.match(first.stdout, /forkpress 0\.1\.13 alpha/); + assert.equal( + spawnSync('test', ['-x', path.join(project, '.forkpress-bin/latest/test-target/forkpress')]).status, + 0, + ); + + rmSync(release, { recursive: true, force: true }); + const second = runLauncher(project, release, 'beta'); + assert.equal(second.status, 0, second.stderr); + assert.equal(second.stderr, ''); + assert.match(second.stdout, /forkpress 0\.1\.13 beta/); +}); + +function runLauncher(project, release, arg) { + return spawnSync('./forkpress', [arg], { + cwd: project, + encoding: 'utf8', + env: { + ...process.env, + FORKPRESS_RELEASE_BASE_URL: `file://${release}`, + FORKPRESS_TARGET: 'test-target', + PATH: process.env.PATH, + }, + }); +} + +function spawn(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'), + encoding: 'utf8', + ...options, + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed\n${result.stderr}`); + } + return result; +} From bf94c525ff44e150d0720e64e513a8c9ae39901e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 15 May 2026 17:15:29 +0200 Subject: [PATCH 6/6] Update Windows release workflow test --- tests/windows/installer-error-surface.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/windows/installer-error-surface.ps1 b/tests/windows/installer-error-surface.ps1 index 7c06e8e5..b9c6bc8f 100644 --- a/tests/windows/installer-error-surface.ps1 +++ b/tests/windows/installer-error-surface.ps1 @@ -28,14 +28,14 @@ function Assert-Contains { $installPath = Join-Path $repoRoot 'scripts\windows\install.ps1' $setupPath = Join-Path $repoRoot 'scripts\windows\setup-dev-drive.ps1' -$releasePath = Join-Path $repoRoot '.github\workflows\release.yml' +$releasePublishPath = Join-Path $repoRoot '.github\workflows\release-publish.yml' Assert-ScriptParses -Path $installPath Assert-ScriptParses -Path $setupPath $install = Get-Content -Raw -LiteralPath $installPath $setup = Get-Content -Raw -LiteralPath $setupPath -$release = Get-Content -Raw -LiteralPath $releasePath +$releasePublish = Get-Content -Raw -LiteralPath $releasePublishPath Assert-Contains $install '\[switch\]\s+\$NoPauseOnError' 'install.ps1 must keep a CI-safe no-pause switch.' Assert-Contains $install 'trap\s*\{' 'install.ps1 must have a top-level trap so installer errors stay visible.' @@ -49,6 +49,6 @@ Assert-Contains $setup 'Get-ForkPressMinimumMemoryBytes' 'setup-dev-drive.ps1 mu Assert-Contains $setup '8000000000' 'Dev Drive RAM threshold should use decimal 8 GB, not 8 GiB.' Assert-Contains $setup 'ForkPress Dev Drive setup cannot continue' 'setup-dev-drive.ps1 must print a visible red fatal error.' -Assert-Contains $release '-NoPauseOnError' 'release smoke tests must pass -NoPauseOnError so CI cannot hang on a visible error prompt.' +Assert-Contains $releasePublish '-NoPauseOnError' 'release publish smoke tests must pass -NoPauseOnError so CI cannot hang on a visible error prompt.' Write-Host 'Windows installer error-surface checks passed.'