Skip to content
Merged
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
61 changes: 61 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions docs/releases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!--
SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
SPDX-License-Identifier: AGPL-3.0-or-later
-->

# 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@<full-commit-sha> # v0.1.0
uses: LibreCodeCoop/github-governance/discover@<full-commit-sha> # 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-commit-sha> # v0.1.0
uses: LibreCodeCoop/github-governance@<v0.1.0-commit-sha> # 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.
28 changes: 28 additions & 0 deletions src/release-validator.ts
Original file line number Diff line number Diff line change
@@ -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.`);
}
}
45 changes: 45 additions & 0 deletions src/validate-release.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
52 changes: 52 additions & 0 deletions tests/release-validator.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading