diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c4a3ace --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +# SPDX-License-Identifier: AGPL-3.0-or-later + +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Release version, including the v prefix (for example v0.1.0) + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + name: Publish versioned release + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + environment: release + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node.js + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 + with: + node-version: 24 + package-manager-cache: false + + - name: Install dependencies + run: npm install --ignore-scripts + + - name: Run project checks + run: npm run check + + - name: Validate release + env: + RELEASE_VERSION: ${{ inputs.version }} + run: node dist/validate-release.js "$RELEASE_VERSION" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + gh release create "$RELEASE_VERSION" \ + --target "$GITHUB_SHA" \ + --title "$RELEASE_VERSION" \ + --generate-notes diff --git a/docs/releases.md b/docs/releases.md new file mode 100644 index 0000000..eea0283 --- /dev/null +++ b/docs/releases.md @@ -0,0 +1,69 @@ + + +# Releases + +Releases provide stable, human-readable versions for consumers while callers +continue pinning the reusable actions to immutable commit SHAs. + +## Publishing + +1. Update the version in `package.json` through a reviewed pull request. +2. Merge only after TypeScript, Vitest, REUSE, actionlint and zizmor pass. +3. Run the `Release` workflow from `main` with the matching `vMAJOR.MINOR.PATCH` + value. +4. The workflow runs the project checks again and creates the Git tag and GitHub + Release from the exact `main` commit that was validated. + +The workflow refuses to release from another branch, rejects malformed versions, +rejects versions that do not match `package.json`, and refuses to overwrite an +existing tag. + +## Consuming the actions + +Consumers should pin the full immutable commit SHA and keep the corresponding +release version as a comment: + +```yaml +uses: LibreCodeCoop/github-governance@ # v0.1.0 +uses: LibreCodeCoop/github-governance/discover@ # v0.1.0 +``` + +The version comment is informational; execution remains pinned to the immutable +SHA. + + +## Environment protection + +Create a `release` environment in GitHub and restrict it to the `main` branch. +Where supported, require reviewer approval for release jobs. + +The workflow also rejects execution outside `main`; the environment restriction +is an independent defense-in-depth control. + +## Post-release + +After publishing a reusable-action release: + +1. record the release commit SHA; +2. update every production caller to that exact SHA; +3. add the real release version as the adjacent comment; +4. run caller CI and an organization-wide dry-run; +5. merge only after the new pin is validated. + +For `v0.1.0`, update both production callers: + +```yaml +uses: LibreCodeCoop/github-governance/discover@ # v0.1.0 +uses: LibreCodeCoop/github-governance@ # v0.1.0 +``` + +Reference callers: + +- `LibreSign/.github` +- `LibreCodeCoop/.github` + +Do not close the release tracking issue until the release is published and all +production callers are pinned to the released commit. diff --git a/src/release-validator.ts b/src/release-validator.ts new file mode 100644 index 0000000..583795a --- /dev/null +++ b/src/release-validator.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +export type ReleaseValidationInput = { + requestedVersion: string; + packageVersion: string; + tagExists: boolean; +}; + +export function validateRelease({ + requestedVersion, + packageVersion, + tagExists, +}: ReleaseValidationInput): void { + if (!/^v\d+\.\d+\.\d+$/.test(requestedVersion)) { + throw new Error('Version must use the vMAJOR.MINOR.PATCH format.'); + } + + if (requestedVersion !== `v${packageVersion}`) { + throw new Error( + `Requested version ${requestedVersion} does not match package.json version v${packageVersion}.`, + ); + } + + if (tagExists) { + throw new Error(`Tag ${requestedVersion} already exists.`); + } +} diff --git a/src/validate-release.ts b/src/validate-release.ts new file mode 100644 index 0000000..ea3814f --- /dev/null +++ b/src/validate-release.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { validateRelease } from './release-validator.js'; + +const requestedVersion = process.argv[2]; + +if (!requestedVersion) { + console.error('Usage: validate-release vMAJOR.MINOR.PATCH'); + process.exitCode = 2; +} else { + try { + const packageJson = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8'), + ) as { version?: unknown }; + + if (typeof packageJson.version !== 'string') { + throw new Error('package.json must contain a string version.'); + } + + let tagExists = true; + try { + execFileSync( + 'git', + ['rev-parse', '--verify', '--quiet', `refs/tags/${requestedVersion}`], + { stdio: 'ignore' }, + ); + } catch { + tagExists = false; + } + + validateRelease({ + requestedVersion, + packageVersion: packageJson.version, + tagExists, + }); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/tests/release-validator.test.ts b/tests/release-validator.test.ts new file mode 100644 index 0000000..2cbe0ce --- /dev/null +++ b/tests/release-validator.test.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 LibreCode coop and contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { describe, expect, it } from 'vitest'; +import { validateRelease } from '../src/release-validator.js'; + +describe('validateRelease', () => { + it('accepts a new version that matches package.json', () => { + expect(() => + validateRelease({ + requestedVersion: 'v0.1.0', + packageVersion: '0.1.0', + tagExists: false, + }), + ).not.toThrow(); + }); + + it.each([ + '0.1.0', + 'v0.1', + 'v0.1.0-beta.1', + 'latest', + ])('rejects unsupported version format: %s', (requestedVersion) => { + expect(() => + validateRelease({ + requestedVersion, + packageVersion: '0.1.0', + tagExists: false, + }), + ).toThrow('vMAJOR.MINOR.PATCH'); + }); + + it('rejects a version that does not match package.json', () => { + expect(() => + validateRelease({ + requestedVersion: 'v0.2.0', + packageVersion: '0.1.0', + tagExists: false, + }), + ).toThrow('does not match package.json version v0.1.0'); + }); + + it('rejects an existing tag', () => { + expect(() => + validateRelease({ + requestedVersion: 'v0.1.0', + packageVersion: '0.1.0', + tagExists: true, + }), + ).toThrow('already exists'); + }); +});